iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 22
3

概覽

大部分的瀏覽器會以每秒 60 次的頻率刷新頁面,反過來說只要瀏覽器來不及在 16 毫秒(1000/60)內產出下一個畫面就會讓使用者感覺卡卡的,影響使用體驗,本文將會介紹如何優化瀏覽器產出畫面的效能,甚至是跳過某些階段來增加效率。(以下用 Rendering 一詞代表產出畫面的過程)

開始之前先簡單介紹一下 Rendering 的各個階段:

  • JavaScript – 用 JavaScript 修改 DOM 和 CSS 產生動畫
  • Style calculations – 計算每個元素的 Computed style
  • Layout – 計算元素的位置、大小
  • Paint – 將元素的文字、顏色、圖片等等繪製在多個 Layer 上
  • Compositing – 以正確的順序將 Layers 合併

更詳細的解釋可以參考 Performance - How Rendering Works

JavaScript

製作動畫除了用 JavaScript 直接修改 DOM,還有 Animation API、CSS Animations、Transitions 等等方式,但歸根究柢都是改變了元素的 Style,而最常見的問題就是花太久時間或是在錯誤的時機修改 Style。

requestAnimationFrame

作為 Rendering 的第一階段,最適合修改 Style 的時機就在每一幀剛開始的時候,然而在各種因素如瀏覽器環境、其他 JavaScript 執行的影響,並不能確定瀏覽器更新畫面的頻率,且有些的動畫套件或範例會使用 setTimeoutsetInterval 來修改樣式,就容易出現太晚執行或是在一幀內修改兩次的狀況。

瀏覽器更新頁面時沒來得及 Render 出下個畫面,且即使執行 setTimeout(callback, 16)不一定會在 16 毫秒後立即執行。

使用 requestAnimationFrame 才能確保 JavaScript 在每一幀的開頭執行,且使用者頁面跳離分頁時會自動停止執行。

function updateScreen(time) {
  // 修改 DOM 來產生動畫
}
requestAnimationFrame(updateScreen);

Worker

雖然每一幀的間隔是 16 毫秒,但扣除其他階段,最安全的執行時間是在 4 毫秒以內,如果動畫計算太過繁重例如排序、搜尋等等,可以把純計算的部分移到 Worker,算完再交由主線程修改 DOM。

另外也可以看看 WorkerDOM,在 Worker 實做了大部分的 DOM API。

Debounce

過於頻繁的修改會浪費效能(16 毫秒內修改多次),最常見的例子就是 Scroll,可以把需要用到的值暫存起來,且避免在一幀的時間內註冊多次 requestAnimationFrame

function onScroll (event) {
  // 把動畫所需的值存起來
  lastScrollY = window.scrollY;

  // 避免一幀內多次修改 DOM
  if (scheduledAnimationFrame) return;

  scheduledAnimationFrame = true;
  requestAnimationFrame(updateScreen);
}

window.addEventListener('scroll', onScroll);

 

Style Calculations

計算一個元素的 Computed style 首先要找出所有匹配該元素的 Selector,再利用所有 Style 算出最終的 Computed style,依據官網所述,Chrome 在計算 Computed style 時有一半的時間都花在 Selector 比對。

Selectors 複雜度

以下列兩個 Selector 為例,前者需要確定該元素是不是偶數順序的子元素、上層元素使否包含 .box-containerbody 有沒有 toggle class,後者只需要確定該元素有沒有 black 這個 class,兩者在效能上有明顯差異。

body.toggled .box-container .box:nth-child(2n) {
  background: #000;
}
.black {
  background: #000;
}

想要切換第偶數個 .box 的背景色時,比起 :nth-child(2n),直接在元素加上 .black 效能會更好,尤其是元素數量非常多的情況:

// this is slower
// document.body.classList.toggle('toggle'); 

const container = document.querySelector('.box-container');
const boxes = container.querySelectorAll('.box');
for (let [index, box] of boxes.entries()) {
  if (index % 2 === 1) {
    box.classList.toggle('black');
  }
}

 

Layout

每次改變 Styles 時瀏覽器都會檢查哪些元素需要重新 Layout,且只要動到一個元素,底下所有子元素都需要重新 Layout。

Layout Thrashing

有些行為會讓瀏覽器強制 Layout,一次可能沒什麼問題,但如果是迴圈就會在一次 Rendering 中觸發多次 Layout。

以這段程式碼為例,讀取元素的 offsetWidth 時瀏覽器需要強制 Layout 才能算正確的寬度,若沒有後續操作還好,如果馬上修改 Style,下次讀取 offsetWidth 時又需要再次 Layout。

const boxes = document.querySelectorAll('.box');
for (let box of boxes) {
  const width = box.offsetWidth; // 強制 Layout
  box.style.width = `${width + 10}px`; // 修改 Style
}

不斷讀寫穿插的行為會引起效能爆炸,稱為 Layout Thrashing,只進行一次讀取或是把狀態儲存起來可以避免:

boxWidth += 10;
const boxes = document.querySelectorAll('.box');
for (let box of boxes) {
  box.style.width = `${boxWidth}px`;
}

FastDOM

FastDOM 利用排序讀寫行為,把「讀寫讀寫讀寫」變為「讀讀讀寫寫寫」來減少 Layout 次數,提升效能,可以看看 Demo 中明顯的效能差異。

What Triggers Layout

只要修改的 Styles 和排版有關都需要 Layout,當然修改 DOM、Resize 也是,但如果只有改變顏色相關的 Styles 就可以跳過 Layout 階段,進行 Paint 和 Compositing,詳細的觸發機制可以參考 CSS Triggers

What Forces Layout

比觸發 Layout 更嚴重的是強制 Layout,就像是「今天以前要做完」跟「現在馬上給我」的差別,也是引起 Layout thrashing 的元凶,具體哪些行為會強制 Layout 可以看 What forces layout / reflow

 

Paint

透過 DevTools 中的 Rendering、Layers 功能可以快速找到 Paint 階段的瓶頸,詳細的 Paint 階段 Debug 方式請參考 Performance - Analyze Painting & Layers。

Layers

為了盡可能的重複利用上次的繪製結果,瀏覽器會把元素獨立到不同 Layer,只重繪有改變的 Layer,除了讓瀏覽器自行判斷外,遇到效能瓶頸時可以用以下兩種 Styles 把元素獨立到不同 Layer:

.layer {
  will-change: transform;
}
.more-layer {
  transform: translateZ(0);
}

降低範圍和複雜度

重繪的範圍是 Layer 中所有元素的聯集,也就是說只要螢幕的左上角和右下角各有一個點,重繪範圍就是整個螢幕。

而重繪時跟模糊有關的 Style 通常會需要更多效能,例如 box-shadowblur-radius

What Triggers Paint

除了 transformopacity 之外,修改任意的 Styles 都會觸發 Paint 階段,反之只修改這兩種 Styles 就能跳過 Layout、Paint 階段。

若實在無法把動畫限制在這兩種 Style,還有另一種做法 FLIP,事先算出動畫的過程,再透過 transformopacity 完成,且 FLIP 還能做到 position: fixed;position: relative; 間的過渡動畫,是 transition 做不到的。

 

Compositing

到了 Compositing 階段,能夠思考的手段就是盡可能減少 Layer 的數量,大部分情況下把元素獨立到不同 Layer 可以提升效能,但事實上這就是以空間換取時間的做法,每建立一層 Layer 都需要額外的記憶體,因此不建議在沒有測量效能的情況下就隨意把元素獨立到新的 Layer。

* {
  will-change: transform;
  transform: translateZ(0);
}

如果對使用者的記憶體和 GPU 有十足把握可以試試

 

Credits

https://developers.google.com/web/fundamentals/performance/rendering/
https://james-priest.github.io/udacity-nanodegree-mws/course-notes/browser-rendering-optimization.html


上一篇
[Day 21] Performance - How Rendering Works
下一篇
[Day 23] Performance - Analyze Paint & Layers
系列文
你所不知道的各種前端 Debug 技巧30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言